Skip to content

fix(extensions): set aside an unusable ledger row instead of failing the file - #1061

Merged
Juliusolsson05 merged 2 commits into
mainfrom
fix/extension-ledger-quarantine
Sep 20, 2026
Merged

Juliusolsson05 merged 2 commits into
mainfrom
fix/extension-ledger-quarantine

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Closes #959.

The bug

readLedger threw for the whole file when any row failed the schema or the api-version gate. List, install, remove, the startup sweep, the scheme handler and every capability check read the ledger first — so one unusable row disabled every installed extension.

And because removeExtension reads before it writes, Remove rejected too. The only recovery was hand-editing ~/.config/agent-code/extensions.json.

The issue's own reproduction (one valid row + one with apiVersion: 3) is now a test.

The fix

A row this build cannot use is preserved verbatim and set aside. The runnable rows load; the set-aside row is reported with its reason and can be removed from Settings.

Decision Why
Carry the raw value, never a repaired one A row stops validating for reasons this build cannot reason about — an extension targeting a newer api version after a rollback, or a schema a future build tightened. We don't know what the field we'd drop means, so the bytes go back untouched and the build that can read it gets its row back intact. That's also why raw is unknown: it's evidence, not data.
writeLedger re-reads the preserved rows rather than taking them as an argument They're invisible to every caller by design (readLedger hides them), so if carrying them were the caller's job, forgetting would silently delete the one row we have the least right to destroy. Re-reading makes losing one impossible rather than merely unlikely, and every caller already holds the ledger lock.
dropPreservedId is the single exception Uninstalling a quarantined extension. It takes an id, never a row, so a caller can't ask for anything except "the preserved row that calls itself this".
Preserved bundles count as referenced by the sweep The sweep runs at startup, so without this a single rollback-and-relaunch deletes the extension's code for good — breaking the exact "existing bundles were preserved" promise the old error message made and the throw then made impossible to keep.
Unparseable JSON / non-array still throws Quarantine preserves a row; when reading rows is what failed there is nothing to set aside, and rewriting such a file is how bundles get orphaned.

Path safety is unchanged. A preserved row's protected paths are built only from an id that independently passes isValidExtensionId and a generation that is a uuid; an id we can't validate yields no path rather than a guess. A protected path is only ever compared against deletion candidates — never served, executed or read from. The QuarantinedExtensionEntry that reaches the renderer carries no manifest: the row failed validation, so nothing on it may be shown as a name, path or version.

Settings

Set-aside rows render below the installed list and visually distinct, because the honest message is "this is not running". A row with no usable id says "Edit extensions.json to remove" rather than offering a button that cannot work.

Tests

ledgerQuarantine.system.test.ts drives the real exported functions against a real ledger file on disk — only the state directory is redirected.

Fail-first, by mutation (each applied and recorded):

Mutation Result
api-version mismatch throws again (the original bug) 5 failed
writeLedger stops carrying preserved rows 2 failed
schema-invalid row throws again 2 failed

npx tsc -b clean; src/main/extensions/ + src/renderer/src/apps/ green (18 files / 206 tests).

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits September 20, 2026 00:26
…the file

Closes #959.

`readLedger` threw for the WHOLE file when any row failed the schema or the
api-version gate. List, install, remove, the startup sweep, the scheme handler
and every capability check read the ledger first — so one unusable row
disabled every installed extension. And because `removeExtension` reads before
it writes, Remove rejected too: the only recovery was hand-editing
~/.config/agent-code/extensions.json.

A row this build cannot use is now preserved verbatim and set aside. The
runnable rows load; the set-aside row is reported with its reason, and the
user can remove it from Settings.

WHY the raw value is carried rather than a repaired one: a row stops
validating for reasons this build cannot reason about — an extension targeting
a NEWER api version after a rollback, or a schema a future build tightened. We
do not know what the field we would drop means, so the bytes go back untouched
and the build that can read it gets its row back intact.

WHY `writeLedger` re-reads the preserved rows instead of taking them as an
argument: they are invisible to every caller by design, so if carrying them
were the caller's job, forgetting would silently delete the one row we have
the least right to destroy. `dropPreservedId` is the single deliberate
exception — uninstalling a quarantined extension — and it takes an id, never a
row.

Their bundles count as REFERENCED by the sweep. The sweep runs at startup, so
without that a single rollback-and-relaunch would delete the extension's code
for good. Paths are still built only from an id that independently passes
`isValidExtensionId` and a generation that is a uuid; an unvalidated row that
cannot be named yields no path rather than a guess, and a protected path is
never served or executed.

A file that is not JSON, or not an array, still throws. Quarantine preserves a
ROW; when reading rows is what failed there is nothing to set aside, and
rewriting such a file is how bundles get orphaned.

Tests drive the real exported functions against a real ledger file on disk,
including the issue's own reproduction. Mutation-checked: restoring the
api-version throw fails 5, dropping preserved rows on write fails 2, restoring
the schema throw fails 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of #1061 found three real defects, one of them destructive.

1. A ledger can hold a preserved row AND a runnable row under the same id —
   a rollback produces exactly that. `removeExtension` computed both
   independently and let both act, so one call did both things in both
   directions: clearing the set-aside row uninstalled the working extension
   and `rm -rf`'d its bundle, and uninstalling the working extension silently
   destroyed the row we have the least right to destroy. The reviewer
   reproduced both on disk.

   Ambiguity between two different objects is not resolvable by a precedence
   rule, so they are now two operations: `removeExtension` touches only
   runnable rows, `removeQuarantinedExtension` only preserved ones, with its
   own IPC channel and preload method. The quarantine remover never deletes a
   bundle — the path would have to come from fields that failed validation,
   and once the row is gone the sweep collects the directory on its own.

2. The sweep's preserved-bundle protection had NO test. Deleting the line
   left all 206 tests passing. The new tests drive the REAL
   `sweepAbandonedInstallDirectories`, not the helper: covering the helper
   alone is what let the protection go untested.

3. Protection was narrower than the rationale claimed. It was keyed on an
   exact path built from the raw row's `installation.id`, which evaporates in
   the one case the feature exists for: a future build that RESHAPES
   `installation` writes rows this build both rejects and cannot locate, so a
   single rollback-and-relaunch deleted the newer build's bundles. Protection
   is now keyed on the extension ID — validated independently, and the one
   level of `.bundles/<id>/<generation>` the sweep itself walks — so it needs
   no guess about what a future generation looks like. The old flat
   `EXTENSIONS_DIR/<id>` path went with it: the sweep never considers that
   level, so it protected nothing while a test asserted it did.

   The cost is stated in the code: an id that is both preserved and runnable
   keeps its superseded generations until the preserved row is removed. A
   stale directory beats deleting code a rolled-forward build needs.

   The remaining limit is stated rather than papered over: a row whose id does
   not validate protects nothing, because we cannot name a directory we have
   no trustworthy name for, and guessing is how the sweep deletes an unrelated
   tree.

Mutation-checked: dropping the sweep protection fails 2, and each direction of
the remove collision fails 1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 merged commit 79a72af into main Sep 20, 2026
2 checks passed
@Juliusolsson05
Juliusolsson05 deleted the fix/extension-ledger-quarantine branch September 20, 2026 08:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(extensions): one unsupported ledger row disables every installed extension

1 participant